Skip to content

feat(enterprise-import): reconcile de emails em sessão persistida - #19

Open
daniloleonecarneiro wants to merge 7 commits into
etusdigital:mainfrom
daniloleonecarneiro:feat/email-reconcile-session
Open

feat(enterprise-import): reconcile de emails em sessão persistida#19
daniloleonecarneiro wants to merge 7 commits into
etusdigital:mainfrom
daniloleonecarneiro:feat/email-reconcile-session

Conversation

@daniloleonecarneiro

@daniloleonecarneiro daniloleonecarneiro commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Reescreve o fluxo super-admin de conciliação de emails mascarados (import Enterprise) para operar em lotes sobre uma sessão server-side, viável para exports grandes (centenas de milhares de contatos, com milhares de colisões de máscara):

  • Sessão persistida (email_reconcile_sessions/_items, migration 1783000000000): CSV parseado e matched uma vez; depois tudo é incremental — apply-auto em chunks, fila de ambíguos paginada, resolve em lote (best-name / skip-remaining), progresso derivado dos status. A substituição da sessão é transacional: rewrite interrompida não deixa cabeçalho com itens parciais.
  • Match em camadas por created_at: o import preserva o timestamp da origem e o export CSV carrega o mesmo campo, então (máscara, created_at) age como chave natural. Tier de minuto exato > mesma data > desempate por similaridade de nome (Jaccard, insensível a pontuação/ordem).
  • Um único offset de timezone por CSV: quando os timestamps não trazem offset, ele é inferido uma vez — pela moda dos deltas dos pares em que a máscara colide 1:1 dos dois lados, onde o pareamento é certo — e o tier exato exige esse valor. Amostra pequena ou sem dominância não vira palpite: o match para no nível de data e a decisão fica com o operador.
  • Formatos de created_at aceitos: ISO/SQL, DD/MM/YYYY e o Date.prototype.toString() que o export em stream emitia (Thu Jul 09 2026 03:48:16 GMT-0300 (...)). O exportador passou a escrever ISO-8601 UTC, mas CSVs baixados antes disso continuam funcionando em vez de virarem created_at nulo — que degradava o match para nome, silenciosamente.
  • Colunas obrigatórias validadas nos headers antes de processar (email, created_at e sinal de nome — coluna name ou par first_name/last_name); formulário de upload trava as obrigatórias e permite ignorar opcionais.
  • Email como chave única por conta em todo o fluxo: arbitragem de auto-picks por email normalizado (exports repetem o mesmo endereço em várias linhas; chavear por linha deixava cada duplicata gerar seu próprio vencedor), pré-checagem no apply em vez do erro cru do índice único, candidatos já usados desabilitados na UI e estratégias automáticas nunca consomem email já aplicado.
  • Endereço em uso vira conflito revisável, não falha: status conflict — terminal para as passadas automáticas (repetir daria o mesmo resultado, e deixar pendente faria o cliente rodar em loop), mas resolvível: o item aparece na fila de revisão, entra no resolve, é varrido pelo skip-remaining e pode ser reaberto em lote (POST session/reopen-conflicts) depois que o operador libera os endereços. Picks automáticos com endereço ocupado já nascem em conflito, sem gastar uma tentativa de apply para descobrir.
  • Visibilidade: tabela "itens da sessão" (quem casou com o quê) com busca por nome/email e filtros de tipo/status (inclusive conflict); busca também na fila de ambíguos; badges de concordância de data; painel de conflitos com ação de reabertura.
  • Cadeia de limites de upload alinhada: nginx client_max_body_size 64m + proxy_read_timeout 300s no /api, body-parser 64mb escopado em /imports, Joi 50MB.
  • Leitura de email cru sob a permissão de export: a máscara do email real é idêntica ao placeholder importado, então contatos reconciliados pareciam continuar mascarados e o endereço utilizável não chegava a quem consome a API. As rotas de leitura passam a devolver o valor armazenado, mas só para quem tem audience:contacts_export (ou super admin) — a mesma fronteira que já protege o export do mesmo dado. Só contacts_view continua vendo a máscara, assim como chamadas sem contexto de principal. Supressões seguem mascaradas.

Test plan

  • reconcile-timestamp.util.spec.ts (13): formatos aceitos/rejeitados, inferência do offset (moda, amostra insuficiente, sem dominância, timestamps que já trazem offset) e os níveis de concordância sob cada caso.
  • email-reconcile.service.spec.ts (31): colunas obrigatórias (ambas as formas de nome), desambiguação por created_at, offset inferido vs ausente, CSV parcial vs colisões de máscara, arbitragem de endereço duplicado e o apply legado.
  • email-reconcile-session.service.spec.ts (18): criação transacional da sessão, conflito pré-marcado, apply-auto (aplicado/conflito/falha e término do loop), resolve alcançando itens em conflito, bulk best-name não consumindo endereço ocupado, reabertura e contadores.
  • enterprise-import.controller.spec.ts (9): contrato dos endpoints da sessão, clamps de paginação e filtros ignorados quando desconhecidos.
  • export-timestamp.util.spec.ts (4) e email-visibility.util.spec.ts (8): regressão do contrato ISO do export e da fronteira de permissão do email cru.
  • reconcile-gateway.test.ts (8, frontend): rotas, ausência de timeout no upload, filtro de conflito e reabertura.
  • Suíte de backend completa verde (64 suítes / 850 testes); tsc --noEmit limpo em backend e frontend; locales pt-BR/en-US/es-ES válidos.
  • Validado e2e no stack docker vivo com um export real: sessão criada, match automático por minuto, apply com re-derivação de hashed_email (listener @BeforeUpdate), auditoria registrada, busca e tabela de itens exercitadas contra a sessão viva.

Notas

  • Swagger nos endpoints da sessão (@ApiOperation/@ApiQuery).
  • O repositório só tem docker-build.yml; não há workflow de teste para rodar as suítes acima no CI. Fica como item de infra em PR próprio.

@daniloleonecarneiro
daniloleonecarneiro force-pushed the feat/email-reconcile-session branch from a4cc4bc to f9fa3ab Compare July 13, 2026 15:49
… match por created_at

Reescreve o fluxo super-admin de conciliação de emails mascarados
(export Enterprise) para operar em lotes sobre uma sessão server-side,
viável para bases reais (350k contatos, CSV 20MB, dezenas de milhares
de colisões de máscara):

- Sessão persistida (email_reconcile_sessions/_items): CSV parseado e
  matched UMA vez; depois tudo é incremental — apply-auto em chunks,
  fila de ambíguos paginada com busca, resolve em lote (best-name /
  skip-remaining) e progresso derivado dos status dos itens.
- Match em camadas: created_at como sinal forte (tier minuto exato
  tolerando offset fixo de timezone, tier mesma data) antes do
  desempate por similaridade de nome (Jaccard, pontuação/ordem
  insensível); import preserva o created_at da origem, então o par
  (máscara, created_at) funciona como chave natural.
- Colunas obrigatórias (email, created_at e sinal de nome — coluna
  name OU par first_name/last_name) validadas nos headers antes de
  processar; formulário de upload trava as obrigatórias e permite
  ignorar opcionais.
- Email tratado como chave única por conta em todo o fluxo: dedup de
  auto-picks disputando a mesma linha, pré-checagem amigável no apply
  (em vez do erro cru do índice único), candidatos já usados
  desabilitados na UI e estratégias automáticas nunca consomem email
  já aplicado (item permanece pendente para decisão manual).
- Tabela "itens da sessão" (quem casou com o quê) com busca por
  nome/email e filtros de tipo/status; badges de concordância de data
  nos candidatos; toggle de seleção nos ambíguos.
- Cadeia de limites de upload alinhada (nginx 64m/300s no /api,
  body-parser 64mb escopado em /imports, Joi 50MB) para o CSV viajar
  embutido no JSON.
…scarar

As rotas de leitura (detalhe por id/uuid, listagem paginada e findAll)
reescreviam o email com maskEmail() na resposta. Como a máscara do email
real é idêntica ao placeholder importado do Enterprise, contatos
reconciliados pareciam continuar mascarados — e consumidores da API não
tinham como obter o endereço utilizável para o fluxo de envios.

Agora o email retorna como está no banco: reconciliado = endereço real;
pendente de reconcile = placeholder (que é o valor armazenado mesmo).
O campo derivado maskedEmail segue disponível no payload e a lista de
supressões permanece mascarada.
@daniloleonecarneiro
daniloleonecarneiro force-pushed the feat/email-reconcile-session branch from f9fa3ab to fd0e2c6 Compare July 13, 2026 15:58
@daniloleonecarneiro daniloleonecarneiro changed the title feat(enterprise-import): reconcile de emails em sessão persistida com match por created_at feat(enterprise-import): reconcile de emails em sessão persistida Jul 13, 2026

@filipecrosk filipecrosk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: not ready to merge

The PR is structurally mergeable, but I found several correctness and integration blockers.

Blocking correctness issues

  1. Timezone matching does not implement a fixed offset. The parser accepts any half-hour offset independently for each candidate, so unrelated timestamps can receive the strongest match tier. It needs to infer/use one consistent offset, or otherwise avoid treating arbitrary offsets as exact matches.

  2. Automatic conflicts are marked as failed instead of remaining pending. When applyAutoChunk() finds that the selected email already belongs to another contact, the item is written as failed. The PR description says these items should remain available for manual resolution.

  3. Email uniqueness deduplication is by CSV row, not normalized email. Duplicate CSV rows with the same email can produce multiple automatic winners. Account-wide uniqueness must be enforced on the normalized email before applying.

  4. The production CSV contract is incompatible with the new parser. Our production /contacts/export-stream currently serializes PostgreSQL Date values through fast-csv as locale strings such as Thu Jul 09 2026 03:48:16 GMT-0400 (...). This parser accepts ISO/SQL and Brazilian formats only, so created_at becomes null and matching silently falls back to name matching. We should change the production exporter to emit ISO timestamps (and add a regression test), or the PR must explicitly support the current format.

  5. Raw email exposure changes the permission boundary. The second commit makes ordinary contact list/detail responses return stored raw emails under contacts_view, while exports remain protected by the stronger contacts_export permission. This needs explicit security/product approval, or should use a narrowly scoped privileged endpoint.

  6. Session replacement is non-atomic. The old session is deleted before the new header/items are fully inserted. An interrupted request can leave a partial session that appears valid.

Standards / completeness

  • No tests cover the persisted session service, controller endpoints, UI workflow, or the raw-email permission change; the added 26 tests mainly cover the matching service.
  • The new REST API and response-contract changes are not documented.
  • GitHub reports no checks and no reviews for this PR.

Validation

I ran the exact head locally:

  • Backend build/type-check: passed
  • Frontend build/type-check: passed
  • Backend: 59 suites / 795 tests passed
  • Frontend: 187 files / 1,781 tests passed
  • Focused reconciliation suite: 26 tests passed

Please address the correctness and permission issues, stabilize the production CSV timestamp format (or update the parser), add endpoint/service/UI tests and API documentation, and enable/require CI checks before merging.

O export em stream escrevia o Date cru do driver no fast-csv, que o
serializava com Date.prototype.toString() — string dependente de locale e
host ("Thu Jul 09 2026 03:48:16 GMT-0300 (...)") que nenhum consumidor
parseia de forma confiável. O reconcile de emails, que casa contatos por
created_at, lia esse campo como nulo e caía silenciosamente no match por
nome.

O contrato agora é ISO-8601 em UTC, isolado em toIsoTimestamp() com teste
de regressão (inclusive de estabilidade entre timezones do host).
…de locale

O nível de concordância exata aceitava qualquer offset alinhado a meia
hora dentro de ±14h, avaliado por candidato: cerca de 57 offsets eram
plausíveis, então linhas sem relação nenhuma alcançavam o tier mais forte
e o created_at deixava de desempatar colisão de máscara.

Agora o offset é UM só por CSV, inferido pela moda dos deltas de pares
confiáveis (inferCsvOffsetMinutes) e exigido igual para o tier exato.
Amostra pequena demais ou sem dominância não vira palpite: o match para
no nível de data, que é a falha segura (decisão do operador).

O parser também passa a ler o formato Date.prototype.toString() do export
antigo — CSVs nesse formato ainda estão na mão do operador e eram
descartados como nulos.
…email

O offset do CSV passa a ser inferido uma vez por execução, a partir dos
pares em que a máscara colide 1:1 dos dois lados — ali o pareamento é
certo, então o delta entre os dois timestamps É o offset do export.
Máscaras em colisão ficam de fora da amostra de propósito: alimentar a
inferência com palpite anula o propósito dela.

A arbitragem de picks automáticos passa a ser por email normalizado e
não por número da linha. Exports repetem o mesmo endereço em várias
linhas, e chavear por linha deixava cada duplicata gerar seu próprio
vencedor automático — o segundo morria depois no índice de unicidade.
O banco garante por email; é por email que se arbitra.
… falha

Item cujo endereço já pertence a outro contato era marcado como failed e
saía do fluxo — não havia nada quebrado ali, havia uma decisão a tomar, e
o operador ficava sem ela. Passa a existir o status conflict: terminal
para as passadas automáticas (repetir daria o mesmo resultado), mas
resolvível — o item aparece na fila de revisão, entra no resolve, é
varrido pelo skip-remaining e pode ser reaberto em lote depois que o
operador libera os endereços (POST session/reopen-conflicts).

Picks automáticos já nascem em conflito quando o endereço está ocupado,
em vez de gastar uma tentativa de apply para descobrir. A busca de donos
de endereço passa a ser em blocos — a lista IN recebia lotes inteiros
(20k) ou a sessão toda (350k em imports reais).

A substituição da sessão também vira transação única: a rewrite
interrompida deixava cabeçalho com itens parciais, que se lê como uma
conciliação completa e silenciosamente truncada.

Inclui specs do serviço de sessão e do controller (ambos sem cobertura
até aqui), Swagger nos endpoints da sessão e o painel de conflitos na
UI.
Devolver o endereço armazenado nas rotas de leitura deixou contacts_view
disclosando o mesmo dado que o export protege com contacts_export — duas
fronteiras diferentes para a mesma informação.

A leitura crua passa a responder à permissão de export (ou super admin);
quem tem só contacts_view volta a receber a máscara. Na prática quem
opera import/reconcile (super admin, admin) segue vendo o endereço real,
e support/analyst não. Sem contexto de principal (job interno) o padrão
é mascarar.

A regra fica isolada em email-visibility.util com specs próprios — a
suíte de contacts.service não compila fora do docker.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants